Skip to content

菜品管理

1.1 公共字段自动填充

  1. 在很多表中有很多重复字段,比如创建人id,创建时间,更新人id,更新时间等,让我们的项目代码冗余,不易维护,公共字段自动填充技术就是来解决这一问题的。

    image-20260313091531057

  2. 实现思路

    • 对于create_time,create_user两个字段,是在进行插入操作时修改的,update_time,update_user两个字段是在插入和修改两个操作中修改的
    • 我们可以选择AOP的方法,通过自定义注解 @AutoFill 来指定增强范围,通过AOP的连接点参数,使用反射的方法给字段赋值
    • 为什么不用切入点表达式?因为不太好确定范围,使用正则表达式的话,需要限制方法名,不灵活
  3. 代码实现

    • 导入依赖

      java
      <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-aop</artifactId>
      </dependency>
    • 自定义注解

      java
      /**
       * 自定义注解,用于标识哪些方法需要增强公共字段填充处理
       */
      @Target(ElementType.METHOD) // 作用在方法上
      @Retention(RetentionPolicy.RUNTIME)
      public @interface AutoFill {
      
          /**
           * 指定数据库操作类型
           * @return
           */
          OperationType value();
      }
    • 切入点表达式

    java
    /**
     * 切入点
     */
    @Pointcut("execution(* com.sky.mapper.*(..)) && @annotation(com.sky.annotation.AutoFill)")
        public void autoFillPointcut() {}
    • 通知

      java
      /**
       * 前置通知(增强逻辑),在通知中进行公共字段的赋值
       */
      @Before("autoFillPointcut()")
      public void autoFill(JoinPoint joinPoint) {
          log.info("开始进行公共字段的填充...");
          // 1. 获取被拦截方法的@AutoFill注解的OperationType值
          MethodSignature signature = (MethodSignature) joinPoint.getSignature();// 获得签名对象
          AutoFill annotation = signature.getMethod().getAnnotation(AutoFill.class);// 获得方法上的注解对象
          OperationType value = annotation.value();// 获得数据库操作类型
          // 2. 获取到被拦截方法的参数(需要被赋值的实体类)
          // 这里做一个约定,要想实现自动填充的话,将被增强参数放在第一个位置
          Object[] args = joinPoint.getArgs();
          if (args == null || args.length == 0) {
              log.debug("AOP增强方法参数有问题!{}",args);
              return;
          }
          Object entity = args[0];
          // 3. 准备赋值的数据
          LocalDateTime now = LocalDateTime.now();
          Long currentId = BaseContext.getCurrentId();
          // 4. 根据OperationType值不同,选择不同的增强逻辑,为实体类通过反射赋值
          if(value == OperationType.INSERT){
              // 为四个字段赋值
              try {
                  Method setCreateTime = entity.getClass().getMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
                  Method setUpdateTime = entity.getClass().getMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
                  Method setCreateUser = entity.getClass().getMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
                  Method setUpdateUser = entity.getClass().getMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
      
                  setUpdateUser.invoke(entity, currentId);
                  setCreateUser.invoke(entity, currentId);
                  setUpdateTime.invoke(entity, now);
                  setCreateTime.invoke(entity, now);
      
              } catch (Exception e) {
                  throw new RuntimeException(e);
              }
      
          } else if (value == OperationType.UPDATE) {
              // 为两个字段赋值
              try {
                  Method setUpdateTime = entity.getClass().getMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
                  Method setUpdateUser = entity.getClass().getMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
      
                  setUpdateUser.invoke(entity, currentId);
                  setUpdateTime.invoke(entity, now);
      
              } catch (Exception e) {
                  throw new RuntimeException(e);
              }
          }
      }

1.2 新增菜品

1.2.1 需求分析和设计

  • 产品原型

    image-20260313160233971

  • 业务规则

    • 菜品名称唯一
    • 菜品必须属于某个分类下,不能单独存在(外键约束以及逻辑外键约束(推荐)、删除分类时需要先将菜品删除完)
    • 新增菜品可以根据情况选择菜品的口味
    • 每个菜品必须对应一张图片

1.2.2 代码实现之-上传文件(阿里云OSS)

  • 添加依赖

    xml
    <dependency>
        <groupId>com.aliyun.oss</groupId>
        <artifactId>aliyun-sdk-oss</artifactId>
        <version>3.17.4</version>
    </dependency>

    如果使用的是Java 9及以上的版本,则需要添加以下JAXB相关依赖。

    xml
    <dependency>
        <groupId>javax.xml.bind</groupId>
        <artifactId>jaxb-api</artifactId>
        <version>2.3.1</version>
    </dependency>
    <dependency>
        <groupId>javax.activation</groupId>
        <artifactId>activation</artifactId>
        <version>1.1.1</version>
    </dependency>
    <!-- no more than 2.3.3-->
    <dependency>
        <groupId>org.glassfish.jaxb</groupId>
        <artifactId>jaxb-runtime</artifactId>
        <version>2.3.3</version>
    </dependency>
  • 定义配置属性类,这样该属性类可以直接拿到配置文件中的属性

    java
    @Component
    @ConfigurationProperties(prefix = "sky.alioss")
    @Data
    public class AliOssProperties {
    
        private String endpoint;
        private String accessKeyId;
        private String accessKeySecret;
        private String bucketName;
    
    }
  • 定义OSS工具类

    java
    @Data
    @AllArgsConstructor
    @Slf4j
    public class AliOssUtil {
    
        private String endpoint;
        private String accessKeyId;
        private String accessKeySecret;
        private String bucketName;
    
        /**
         * 文件上传
         *
         * @param bytes
         * @param objectName
         * @return
         */
        public String upload(byte[] bytes, String objectName) {
    
            // 创建OSSClient实例。
            OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
    
            try {
                // 创建PutObject请求。
                ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(bytes));
            } catch (OSSException oe) {
                System.out.println("Caught an OSSException, which means your request made it to OSS, "
                        + "but was rejected with an error response for some reason.");
                System.out.println("Error Message:" + oe.getErrorMessage());
                System.out.println("Error Code:" + oe.getErrorCode());
                System.out.println("Request ID:" + oe.getRequestId());
                System.out.println("Host ID:" + oe.getHostId());
            } catch (ClientException ce) {
                System.out.println("Caught an ClientException, which means the client encountered "
                        + "a serious internal problem while trying to communicate with OSS, "
                        + "such as not being able to access the network.");
                System.out.println("Error Message:" + ce.getMessage());
            } finally {
                if (ossClient != null) {
                    ossClient.shutdown();
                }
            }
    
            //文件访问路径规则 https://BucketName.Endpoint/ObjectName
            StringBuilder stringBuilder = new StringBuilder("https://");
            stringBuilder
                    .append(bucketName)
                    .append(".")
                    .append(endpoint)
                    .append("/")
                    .append(objectName);
    
            log.info("文件上传到:{}", stringBuilder.toString());
    
            return stringBuilder.toString();
        }
    }
  • 定义配置类

    java
    /**
     * 创建一个AliOssUtil类对象交给SpringIOC容器管理
     */
    @Slf4j
    @Configuration
    public class OssConfiguration {
    
        @Bean
        @ConditionalOnMissingBean
        public AliOssUtil aliOssUtil(AliOssProperties aliOssProperties) {
            return new AliOssUtil(aliOssProperties.getEndpoint(),
                    aliOssProperties.getAccessKeyId(),
                    aliOssProperties.getAccessKeySecret(),
                    aliOssProperties.getBucketName());
        }
    
    }

    @ConditionalOnMissingBean,对象是单例模式,容器中同一时刻只能存在一个对象。

    注意:在后面的开发中遇到了问题,我修改了AliyunOSSUtils类的endpoint属性,导致我连续上传文件时,解析路径时数组越界,所以对于这种由配置类声明的工具类,不要随便改属性,会影响容器中的那个对象,是全局生效的。

  • 写Controller

    java
        /**
         * 利用阿里云OSS服务完成文件的上传
         * @param file
         * @return
         */
        @PostMapping("/upload")
        public Result<String> upload(@RequestParam("file") MultipartFile file) {
            log.info("文件上传:{}", file.getOriginalFilename());
            // 在Bucket上的储存路径
            // 目录
            String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
            // 文件名 : UUID + 文件后缀名
            String newFileName = UUID.randomUUID() + file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf("."));
            String objectName = dir + "/" + newFileName;
            try {
                String upload = aliOssUtil.upload(file.getBytes(), objectName);
                return Result.success(upload);
            } catch (IOException e) {
                log.error("文件上传失败,{}",e);
            }
            return Result.error("文件上传失败!");
        }
    }
  • 需要在yml配置文件中写入参数

    yml
    sky:
      alioss:
        endpoint: ${sky.alioss.endpoint}
        bucket-name: ${sky.alioss.bucket-name}
        access-key-id: ${sky.alioss.access-key-id}
        access-key-secret: ${sky.alioss.access-key-secret}

    这里使用了引用的方式赋值,在不同的环境下可以配置不同的OSS服务

1.2.3 主键回填

在 MyBatis 中,主键回填(Generated Keys) 是指在执行 INSERT 语句后,由数据库自动生成的自增主键(如 MySQL 的 AUTO_INCREMENT)被自动赋值回到 Java 实体对象的过程。

这在你的“苍穹外卖”项目中至关重要,因为你需要先插入菜品(Dish),拿到生成的 ID 后,才能给口味表(DishFlavor)设置关联的 dish_id


  1. 如何在 XML 中开启主键回填?

你需要在 <insert> 标签中添加两个关键属性:

  • useGeneratedKeys="true":告诉 MyBatis 使用数据库内置的生成机制获取主键。
  • keyProperty="id":指定将获取到的主键值赋值给传入对象的哪一个属性(通常是实体类中的 id 字段)。

XML 配置示例:

xml
<insert id="save" useGeneratedKeys="true" keyProperty="id">
    insert into dish (name, category_id, price, image, description, status, create_time, update_time, create_user, update_user)
    values (#{name}, #{categoryId}, #{price}, #{image}, #{description}, #{status}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser})
</insert>

  1. 在 Java 代码中如何获取?

执行完 Mapper 方法后,你不需要去调用类似 getGeneratedId 的方法。MyBatis 会直接修改你传入的那个对象。

java
@Override
@Transactional
public void saveWithFlavor(DishDTO dishDTO) {
    Dish dish = new Dish();
    BeanUtils.copyProperties(dishDTO, dish);

    // 1. 执行插入操作
    // 此时 dish.getId() 为 null
    dishMapper.save(dish);

    // 2. 核心时刻:执行完上面那行后,MyBatis 已经把 ID 填进去了
    Long newDishId = dish.getId();
    log.info("生成的新菜品 ID 为:{}", newDishId);

    // 3. 后续业务:给口味关联这个 ID
    List<DishFlavor> flavors = dishDTO.getFlavors();
    if (flavors != null && !flavors.isEmpty()) {
        flavors.forEach(f -> f.setDishId(newDishId));
        dishFlavorMapper.insertBatch(flavors);
    }
}

  1. 注意事项与常见错误

  2. 返回值的误解

    Mapper 方法的返回值 int 依然是受影响的行数(通常是 1),而不是主键 ID。ID 是通过对象的 getter 方法拿到的。

  3. XML 属性拼写

    keyProperty 必须对应实体类中的属性名(如 id),而不是数据库中的列名(如 id)。如果属性名不匹配,回填会失败且不会报错,只是 getId() 拿不到值。

  4. 批量插入的回填

    如果你在做口味表的批量插入(insertBatch),同样可以使用这两个属性。MyBatis 会尝试将生成的一组 ID 对应填入 List 中的每一个实体对象中。

  5. SQL 语句

    不要在 XML 的 SQL 语句里写 id 字段及其值。主键字段应该交给数据库自动维护。


💡 生产环境小技巧

在调试时,如果发现 dish.getId() 拿到的是 0null,请第一时间检查两个地方:

  • 数据库表结构id 字段是否勾选了 AUTO_INCREMENT
  • MyBatis XML:是否漏写了 useGeneratedKeys="true"

1.3 菜品分页查询

  1. 传递数据采用了DishPageQueryDTO,返回数据时使用了DishVO,涉及到了菜品表和分类表的多表查询。
  2. 在业务上,因为查询的是菜品,不管菜品符不符合连接条件,都要返回,采用了左外连接

1.4 删除菜品

  1. 当该菜品处于在售状态,不允许删除

  2. 可以一次删除一个或多个菜品

  3. 删除菜品后,关联的口味数据也要删除

  4. 被套餐关联的菜品不能删除,需要先在套餐中移除菜品,才可删除

  5. 接口设计成一次删除多个菜品的接口,把参数放进集合中传递过来,遍历集合,删除菜品及关联的数据,需要开启事务

  6. 接口:DELETE /admin/dish/ids=1,2,3

  7. 参数类型:String,可以直接使用SpringMVC的@RequestParam注解,自动将参数映射成List<Long>

  8. 实际开发中遇到的问题:

    • 错误考虑了在Java代码中使用遍历一条一条的删除数据,没有想到SQL的批量删除 &lt;foreach&gt;+ in()
  9. MyBatis <foreach> 标签的使用

    • 案例:

      xml
      <delete id="deleteByIds">
          delete from dish where id in
          <foreach collection="ids" item="id" separator="," open="(" close=")">
              #{id}
          </foreach>
      </delete>
      sql
      批量删除 DELETE FROM dish WHERE id IN (1, 2, 3)
      xml
      <insert id="insertBatch">
          insert into dish_flavor (dish_id, name, value) values
          <foreach collection="flavors" item="f" separator=",">
              (#{f.dishId}, #{f.name}, #{f.value})
          </foreach>
      </insert>
      sql
      批量插入 INSERT INTO dish_flavor (...) VALUES (id, name, val), (id, name, val)...

1.5 修改菜品

  1. 需要查询回显,根据ID查询菜品接口,查询菜品分类接口(已实现),图片上传接口(已实现),修改菜品,四个接口

  2. 查询菜品回显时,封装成DishVO对象返回,其中的flavors字段是个集合,需要连表查询再映射,这里如何解决这个映射的问题?

    这里视频中的方法是分步查询,先查除口味之外的数据,用到 dish 和 category 两个表进行连表查询;之后再查菜品的口味数据,最后将所有的数据封装到一个DishVO对象中返回

  3. 修改菜品涉及到dish表,dish_flavor表,setmeal_dish表,需要事务

  4. 对于dish_flavor表,由于数据比较复杂,细致调整很麻烦,所以我们拆分为两步操作:删掉原数据,再添加新数据。在添加新数据的时候,我们需要获取菜品的ID,前端并没有将该ID传过来。

  5. 对于setmeal_dish表,我们选择同步更新,保持数据一致性,对于这种表,是根据实际业务来决定如何处理的